You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
376 lines
12 KiB
376 lines
12 KiB
<script setup lang="ts">
|
|
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
|
|
import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter'
|
|
import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url'
|
|
import { safeExternalHref } from '../../utils/safe-external-href'
|
|
import { formatOccurredOnDisplay, formatPublishedDateOnly, occurredOnToIsoAttr } from '../../utils/timeline-datetime'
|
|
|
|
definePageMeta({
|
|
layout: 'public',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const slug = computed(() => route.params.publicSlug as string)
|
|
|
|
type PublicPostListItem = {
|
|
title?: string | null
|
|
excerpt?: string | null
|
|
slug?: string | null
|
|
publishedAt?: Date | string | null
|
|
coverUrl?: string | null
|
|
}
|
|
|
|
type PublicTimelineItem = {
|
|
id?: number
|
|
title?: string | null
|
|
occurredOn?: Date | string | null
|
|
linkUrl?: string | null
|
|
bodyMarkdown?: string | null
|
|
}
|
|
|
|
type PublicRssListItem = {
|
|
title?: string | null
|
|
canonicalUrl?: string | null
|
|
canonical_url?: string | null
|
|
}
|
|
|
|
type ModulePayload<T> = {
|
|
items?: T[]
|
|
total?: number
|
|
}
|
|
|
|
type Payload = {
|
|
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
|
|
bio: { markdown: string } | null
|
|
links: { label: string; url?: string; href?: string; visibility: string; icon?: string }[]
|
|
modules?: {
|
|
posts?: ModulePayload<PublicPostListItem>
|
|
timeline?: ModulePayload<PublicTimelineItem>
|
|
reading?: ModulePayload<PublicRssListItem>
|
|
}
|
|
posts?: ModulePayload<PublicPostListItem>
|
|
timeline?: ModulePayload<PublicTimelineItem>
|
|
rssItems?: ModulePayload<PublicRssListItem>
|
|
}
|
|
|
|
type NormalizedModule<T> = {
|
|
items: T[]
|
|
total: number
|
|
}
|
|
|
|
function normalizeModule<T>(primary?: ModulePayload<T>, fallback?: ModulePayload<T>): NormalizedModule<T> {
|
|
const source = primary ?? fallback
|
|
return {
|
|
items: Array.isArray(source?.items) ? source.items : [],
|
|
total: typeof source?.total === 'number' ? source.total : 0,
|
|
}
|
|
}
|
|
|
|
function rssPublicHref(it: PublicRssListItem): string | undefined {
|
|
const u = it.canonicalUrl ?? it.canonical_url
|
|
return safeExternalHref(u)
|
|
}
|
|
|
|
function rssPublicTitle(it: PublicRssListItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名'
|
|
}
|
|
|
|
function postPublicTitle(it: PublicPostListItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名文章'
|
|
}
|
|
|
|
function postPublicExcerpt(it: PublicPostListItem): string {
|
|
const e = it.excerpt
|
|
return typeof e === 'string' ? e : ''
|
|
}
|
|
|
|
function postPublicSlug(it: PublicPostListItem): string {
|
|
const s = it.slug
|
|
return typeof s === 'string' ? s : ''
|
|
}
|
|
|
|
function timelinePublicTitle(it: PublicTimelineItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名动态'
|
|
}
|
|
|
|
function timelinePublicBody(it: PublicTimelineItem): string {
|
|
const b = it.bodyMarkdown
|
|
return typeof b === 'string' ? b : ''
|
|
}
|
|
|
|
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
|
|
return e.id ?? i
|
|
}
|
|
|
|
function socialHref(link: { url?: string; href?: string }): string | undefined {
|
|
const value = link.url ?? link.href
|
|
return safeExternalHref(value, { allowMailto: true })
|
|
}
|
|
|
|
function rssHostname(href: string | undefined): string {
|
|
if (!href) {
|
|
return ''
|
|
}
|
|
try {
|
|
return new URL(href).hostname
|
|
}
|
|
catch {
|
|
return href
|
|
}
|
|
}
|
|
|
|
const { data, pending, error } = await useAsyncData(
|
|
() => `public-profile-${slug.value}`,
|
|
async () => {
|
|
const res = await $fetch<ApiResponse<Payload>>(`/api/public/profile/${encodeURIComponent(slug.value)}`)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [slug] },
|
|
)
|
|
|
|
const postsModule = computed(() => normalizeModule(data.value?.modules?.posts, data.value?.posts))
|
|
const timelineModule = computed(() => normalizeModule(data.value?.modules?.timeline, data.value?.timeline))
|
|
const readingModule = computed(() => normalizeModule(data.value?.modules?.reading, data.value?.rssItems))
|
|
|
|
const BIO_PREVIEW_MAX_CHARS = 140
|
|
|
|
const bioSummary = computed(() => {
|
|
const md = data.value?.bio?.markdown
|
|
if (!md?.trim()) {
|
|
return ''
|
|
}
|
|
const desc = extractFrontMatterDesc(md)
|
|
if (typeof desc === 'string' && desc.trim().length > 0) {
|
|
return desc.trim()
|
|
}
|
|
return stripFrontMatter(md).trim()
|
|
})
|
|
|
|
const bioPreviewText = computed(() => {
|
|
const plain = bioSummary.value.replace(/\s+/g, ' ').trim()
|
|
if (!plain) {
|
|
return ''
|
|
}
|
|
if (plain.length <= BIO_PREVIEW_MAX_CHARS) {
|
|
return plain
|
|
}
|
|
return `${plain.slice(0, BIO_PREVIEW_MAX_CHARS).trimEnd()}...`
|
|
})
|
|
|
|
const hasBioPreview = computed(() => bioPreviewText.value.length > 0)
|
|
|
|
const socialLinks = computed(() =>
|
|
(data.value?.links ?? [])
|
|
.map(link => ({ ...link, safeHref: socialHref(link) }))
|
|
.filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0),
|
|
)
|
|
|
|
const readingPreviewItems = computed(() =>
|
|
readingModule.value.items.slice(0, 2).map(item => ({
|
|
item,
|
|
href: rssPublicHref(item),
|
|
})),
|
|
)
|
|
const hasModuleContent = computed(() =>
|
|
postsModule.value.total > 0 || timelineModule.value.total > 0 || readingModule.value.total > 0,
|
|
)
|
|
|
|
const canonicalUrl = computed(() =>
|
|
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
|
|
)
|
|
|
|
useHead(() => ({
|
|
link: canonicalUrl.value
|
|
? [{ rel: 'canonical', href: canonicalUrl.value }]
|
|
: [],
|
|
}))
|
|
|
|
usePageTitle(() => {
|
|
const s = slug.value
|
|
const d = data.value
|
|
if (!d) {
|
|
return [`@${s}`, '主页']
|
|
}
|
|
const name = d.user.nickname || (d.user.publicSlug ? `@${d.user.publicSlug}` : '') || s
|
|
return [name, '主页']
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="pending && !data" class="text-muted py-10">
|
|
<UContainer>加载中…</UContainer>
|
|
</div>
|
|
<UContainer v-else-if="error && !data" class="py-10">
|
|
<UAlert color="error" title="无法加载主页" />
|
|
</UContainer>
|
|
<UContainer
|
|
v-else-if="data"
|
|
class="max-w-2xl py-10 sm:py-14"
|
|
>
|
|
<div class="mx-auto flex w-full max-w-xl flex-col gap-10 sm:gap-12">
|
|
<section
|
|
class="w-full rounded-2xl border border-default/80 bg-elevated/25 px-6 py-8 text-center shadow-sm ring-1 ring-black/5 dark:ring-white/10 sm:px-10 sm:py-9"
|
|
aria-label="个人资料"
|
|
>
|
|
<div class="flex flex-col items-center gap-4">
|
|
<div v-if="data.user.avatar" class="flex justify-center">
|
|
<img
|
|
:src="data.user.avatar"
|
|
alt=""
|
|
class="h-24 w-24 rounded-full border-2 border-default object-cover shadow-md ring-4 ring-primary/10"
|
|
>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<h1 class="text-balance text-2xl font-semibold tracking-tight text-highlighted sm:text-3xl">
|
|
{{ data.user.nickname || data.user.publicSlug || slug }}
|
|
</h1>
|
|
<p
|
|
v-if="data.user.nickname && data.user.publicSlug"
|
|
class="text-sm font-medium tabular-nums text-muted"
|
|
>
|
|
@{{ data.user.publicSlug }}
|
|
</p>
|
|
</div>
|
|
<ul
|
|
v-if="socialLinks.length"
|
|
class="flex flex-wrap items-center justify-center gap-2.5"
|
|
aria-label="社交链接"
|
|
>
|
|
<li v-for="(l, i) in socialLinks" :key="i">
|
|
<a
|
|
:href="l.safeHref"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
:title="l.label"
|
|
:aria-label="`${l.label}(新窗口打开)`"
|
|
class="flex size-10 items-center justify-center rounded-full border border-default/80 bg-default/40 text-primary transition-colors hover:border-primary/35 hover:bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<UIcon
|
|
:name="socialLinkIconName({ ...l, url: l.safeHref || '' })"
|
|
class="size-5 shrink-0 opacity-90"
|
|
/>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="hasBioPreview"
|
|
class="w-full"
|
|
>
|
|
<NuxtLink
|
|
:to="`/@${slug}/about`"
|
|
class="block rounded-2xl border border-default/70 bg-elevated/20 p-5 transition-colors hover:border-primary/25 hover:bg-elevated/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:p-6"
|
|
>
|
|
<p class="text-sm leading-relaxed text-toned sm:text-base">
|
|
{{ bioPreviewText }}
|
|
</p>
|
|
<p class="mt-3 text-xs font-medium text-primary">
|
|
查看完整介绍
|
|
</p>
|
|
</NuxtLink>
|
|
</section>
|
|
|
|
<template v-if="hasModuleContent">
|
|
<HubModuleCard
|
|
title="文章"
|
|
:total="postsModule.total"
|
|
description="最近发布的文章摘要,进入后可查看完整内容。"
|
|
:to="`/@${slug}/posts`"
|
|
>
|
|
<template #preview>
|
|
<NuxtLink
|
|
v-for="(p, i) in postsModule.items.slice(0, 2)"
|
|
:key="`${postPublicSlug(p)}-${i}`"
|
|
:to="`/@${slug}/posts/${encodeURIComponent(postPublicSlug(p))}`"
|
|
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
|
|
>
|
|
<div class="text-sm font-medium text-highlighted">
|
|
{{ postPublicTitle(p) }}
|
|
</div>
|
|
<time
|
|
v-if="p.publishedAt"
|
|
class="mt-1 block text-xs tabular-nums text-muted"
|
|
:datetime="occurredOnToIsoAttr(p.publishedAt)"
|
|
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
|
|
<p v-if="postPublicExcerpt(p)" class="mt-1 line-clamp-2 text-xs text-muted">
|
|
{{ postPublicExcerpt(p) }}
|
|
</p>
|
|
</NuxtLink>
|
|
</template>
|
|
</HubModuleCard>
|
|
|
|
<HubModuleCard
|
|
title="时光机"
|
|
:total="timelineModule.total"
|
|
description="近期动态与节点,仅展示入口预览。"
|
|
:to="`/@${slug}/timeline`"
|
|
>
|
|
<template #preview>
|
|
<div
|
|
v-for="(e, i) in timelineModule.items.slice(0, 2)"
|
|
:key="timelineItemKey(e, i)"
|
|
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2"
|
|
>
|
|
<div class="text-sm font-medium text-highlighted">
|
|
{{ timelinePublicTitle(e) }}
|
|
</div>
|
|
<time
|
|
v-if="e.occurredOn"
|
|
class="mt-1 block text-xs tabular-nums text-muted"
|
|
:datetime="occurredOnToIsoAttr(e.occurredOn)"
|
|
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
|
|
<p v-if="timelinePublicBody(e)" class="mt-1 line-clamp-2 text-xs text-muted">
|
|
{{ timelinePublicBody(e) }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
</HubModuleCard>
|
|
|
|
<HubModuleCard
|
|
title="阅读"
|
|
:total="readingModule.total"
|
|
description="最近收藏/阅读条目,进入阅读页获取完整列表。"
|
|
:to="`/@${slug}/reading`"
|
|
>
|
|
<template #preview>
|
|
<template v-for="({ item, href }, i) in readingPreviewItems" :key="i">
|
|
<a
|
|
v-if="href"
|
|
:href="href"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
|
|
>
|
|
<div class="text-sm font-medium text-highlighted">
|
|
{{ rssPublicTitle(item) }}
|
|
</div>
|
|
<div class="mt-1 text-xs text-muted">
|
|
{{ rssHostname(href) }}
|
|
</div>
|
|
</a>
|
|
<div
|
|
v-else
|
|
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2"
|
|
>
|
|
<div class="text-sm font-medium text-highlighted">
|
|
{{ rssPublicTitle(item) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</HubModuleCard>
|
|
</template>
|
|
<UEmpty
|
|
v-else
|
|
title="这里还没有公开内容"
|
|
description="站主尚未发布任何公开文章或动态。"
|
|
/>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
|